Skip to content

fix(core): ValueDataSource's contains is case-sensitive and icontains is its ASCII-folding twin - #8437

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-7379-valuedatasource-contains-case
Sep 7, 2026
Merged

fix(core): ValueDataSource's contains is case-sensitive and icontains is its ASCII-folding twin#8437
os-justin merged 1 commit into
mainfrom
claude/issue-7379-valuedatasource-contains-case

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #7379

The escalation fence resolves to DEFECT, and here is the measurement that decides it

The card's fence: if the spec keeps contains and icontains deliberately distinct and ValueDataSource is the odd one out among the drivers, it is a defect; if it is the only driver, or the drivers disagree, it is a product ruling. It is the first. The sibling drivers' behaviour, not the spec text:

face $contains case $icontains measured how
driver-sql SENSITIVE ASCII fold imports FILTER_TEXT_CASES (sql-driver-text-case-conformance.test.ts, sql-driver-icontains-and-retired-operators.test.ts)
driver-sqlite-wasm SENSITIVE (GLOB) ASCII fold imports the table (sqlite-wasm-icontains-and-retired-operators.test.ts)
driver-turso SENSITIVE ASCII fold imports the table (turso-local-remote-text-parity.test.ts)
driver-mongodb SENSITIVE since #6682 ASCII fold imports the whole table (mongodb-filter-text-conformance.test.ts)
driver-memory SENSITIVE since #6682 ASCII fold imports the whole table, on every face (memory-filter-text-conformance.test.ts)
objectql having SENSITIVE asciiCaseInsensitiveContains having-filter.ts:413 / :455, pinned by having-filter-text-conformance.test.ts
formula SENSITIVE ASCII fold matches-filter-icontains.test.ts
@object-ui/core ValueDataSource INSENSITIVE same body as contains this PR

The canonical rows those eight faces answer are FILTER_TEXT_CASES in @objectstack/spec/data, whose $contains is case-SENSITIVE case reads: { name: { $contains: 'acme' } } over ACME Corp / acme corp selects row 2 only. So the disagreement is with the other implementations, not with a document.

Two things I measured rather than assumed, both by executing the installed @objectstack/spec 17.3.0:

  • asciiCaseInsensitiveContains('CAFÉ', 'café') is false, while 'CAFÉ'.toLowerCase().includes('café') is true. The fold is ASCII-only by ruling (objectstack#4706 Q1 = A) because three of five backends are SQLite underneath and its lower() folds ASCII only.
  • No i twin exists for the other three operators. VALID_AST_OPERATORS.has(...) answered: icontains true; istartswith, iendswith, noticontains, not_icontains all false. The triage seat asked for exactly this and told me not to assume duals — there are none, so case-sensitive is the only reading starts_with / ends_with / not_contains have.

What changed

Five arms in packages/core/src/adapters/ValueDataSource.ts, all of which lower-cased both sides:

  1. contains, not_contains, starts_with, ends_with compare exactly. Judged as one group per the triage seat's boundary — a case-exact contains beside a folding not_contains lets ACME Corp fail an operator and its negation.
  2. icontains gets its own arm and takes the spec's exported asciiCaseInsensitiveContains. Before this it shared the contains body, so PR fix(core): teach ValueDataSource's matcher the filter vocabulary the wire already has #7377's mapping made the two identical; now it is the one case-insensitive member, folding ASCII on both sides.
  3. The $-dialect matcher (matchesFilter) follows: $contains compares exactly, and $icontains gains an arm. It had none — an unrecognised $ operator in that switch reaches default: break, which adds no constraint, so { name: { $icontains: 'acme' } } used to select every row. Making $contains exact without adding its twin would have left that dialect with no working case-insensitive door.

The direction is written at the arm (the card's third acceptance criterion), naming the ruling and the drivers, so the next reader does not take it for a typo.

Deliberately not touched: canonicalAstOperator (#7349 / PR #7377's work, and correct), and the $search path's toLowerCase() — full-text search is case-insensitive by ruling (objectstack#7641), a different question from a filter operator.

The pin

packages/core/src/adapters/__tests__/ValueDataSource.textOperatorCase.test.ts, 14 cases. Every assertion is a row-set equality, never an operator-name check: a matcher that returns every row constructs exactly the right operator string.

Both halves over one fixture (ACME Corp / acme corp / CAFÉ / café):

  • EXCLUSION['name','contains','acme'] selects ['lower']; ACME Corp must not come back.
  • INCLUSION['name','icontains','acme'] over the same fixture selects ['upper','lower'], i.e. the row contains just dropped.

Would an implementation strictly worse than the bug pass it? No, and that is its own named case — a matcher answering [] for everything FAILS these cases, not just passes the exclusions. Ablation leg 2 below proves it fires.

Ablation — two legs, both from the committed implementation

Leg 1 — restore the case-fold at the contains READ SITE. On-disk proof: HEAD:...ValueDataSource.ts blob 2722d1ba8dff3cc8b5d7defe81b2c556b86c93ca, mutated on-disk git hash-object 2a25e57da0dd179b64430a25cdf5258c58b892f6 — different, so the mutation reached disk. Result 5 failed | 9 passed, red by name:

× a lower-case comparand: `contains` misses the upper-case row, `icontains` takes both
    -> `contains` is case-SENSITIVE — ACME Corp must NOT come back:
       expected [ 'upper', 'lower' ] to deeply equal [ 'lower' ]
× an upper-case comparand: the mirror, so neither direction is a lucky count
× the two operators cannot silently re-converge: they disagree on this fixture
× `not_contains` is case-sensitive, and complements `contains` exactly
× both dialects answer the same rows for the same question

Leg 2 — the strictly-worse implementation: both dialects' icontains answer nothing. Blob 2722d1ba… vs mutated 4a0f83a7acf71bf175580839f56492007adaa149. Result 6 failed | 8 passed, headed by:

× a matcher answering [] for everything FAILS these cases, not just passes the exclusions
× a lower-case comparand: `contains` misses the upper-case row, `icontains` takes both
× É does not fold to é, in either direction
× $contains excludes the case-differing row and $icontains includes it

Both legs restored by state, not by exit code: git diff HEAD empty, git status --short empty, and on-disk git hash-object back to 2722d1ba…. The pin re-ran 14 passed (14) afterwards. No rebuild step is involved: the pin imports ../ValueDataSource (relative source), so there is no dist/ hop that could stale the reading.

Verification, on the final commit c889b18a7

  • pnpm exec vitest run packages/core/ plus the 67 other test files that reach a value-provider datasource: Test Files 190 passed (190) · Tests 3364 passed (3364).
  • That 67-file set is a measured narrowing, not a guess: the change is confined to two functions in ValueDataSource, so a test can only observe it by (a) reaching that adapter and (b) passing one of the six operator spellings. The union above is condition (a) over the whole repo — 76 files across 12 packages, every one run. turbo ls --affected names 38 packages because @object-ui/core is a base dependency; those are declared to CI's four shards.
  • pnpm --filter @object-ui/core type-check (tsc --noEmit && tsc -p tsconfig.test.json): exit 0. The new file is in the program — tsc -p tsconfig.test.json --listFiles lists it (1 hit), lit control adapters/ValueDataSource.ts 1 hit, negative control 0 hits.
  • eslint . in packages/core: 518 problems (0 errors, 518 warnings), all pre-existing no-explicit-any.
  • node scripts/check-changeset-presence.mjs: ✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/wise-poems-behave.md.
  • node scripts/check-changeset-no-major.mjs: ✅ No changeset declares a 'major' bump.
  • Control-character self-scan over the three touched source files: zero hits.

Changeset level: minor

Scored minor because this moves shipped results — metadata relying on the lenient matching gets fewer rows and no error — and the repo forbids major (every package is in one fixed group, so one major carries all of them off the pinned @objectstack major; check-changeset-no-major.mjs enforces it). patch would be wrong: this is not a fix that leaves behaviour where it was. The changeset body names the migration explicitly — a filter that means "match regardless of case" is authored as icontains or $icontains, and both now execute.

Dependency range: checked, no bump needed

packages/core declares "@objectstack/spec": "^17.2.0". asciiCaseInsensitiveContains is present in 17.2.0's published api-surface/data.json and its dist/data/index.d.ts (read from the 17.2.0 tarball; positive control canonicalAstOperator present, negative control absent). So the declared range is honest and stays.

Two out-of-scope findings, both MEASURED, neither fixed here

Reported for filing — search_issues returned API rate limit already exceeded for user ID 323634890 on both dedupe queries, and repo-scoped REST is 403 for this seat, so I could not search and therefore did not file.

A. matchesFilter waves through every $ operator it does not recognise. Same defect class as objectui#7349, in the dialect #7349 did not touch. Measured over rows n (score: 5) and s (score: '5'):

{ score: { $nin: [5,'5'] } }      -> ['n','s']   (must be [])
{ score: { $startsWith: 'zzz' } } -> ['n','s']   (must be [])
{ score: { $null: true } }        -> ['n','s']   (neither row is null)
{ score: { $exists: false } }     -> ['n','s']

The implemented set is $gt $gte $lt $lte $ne $in $contains plus the $icontains this PR adds; everything else is silently no-constraint. Not fixed here: closing it means refusing unknown operators the way matchesASTFilter does, which moves results for every one of those spellings — its own card, its own escalation.

B. not_contains answers NO for a stored value that is not a string, so a row fails the operator AND its negation. Measured: ['score','contains','5'] selects ['s'] (correct — a number cannot contain a substring) but ['score','not_contains','5'] selects [], dropping row n from the negation too. The platform ruled this cell on 2026-09-05 (objectstack#14079, option A): a non-string never satisfies a positive text operator and does satisfy $notContains. FILTER_TEXT_CASES pins it on every face; ValueDataSource has exactly the shape the reference matcher had before that card. Different axis from this one (stored-value type, not case folding), so it is not ridden in here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…ins` is its ASCII-folding twin

The in-memory matcher lower-cased both sides of `contains`, `not_contains`,
`starts_with`, `ends_with` — and `icontains`, whose arm was stacked onto the
`contains` one. So `contains` executed `icontains`, the two spellings named one
predicate, and a `provider: 'value'` list filtered with `contains` returned
strictly more rows than the same filter run against a real driver. Nothing
errored; the list was just longer.

`$contains` is contractually case-SENSITIVE (objectstack#4706 Q2 = A) and
`$icontains` folds ASCII ONLY (Q1 = A). All five backends plus objectql's
`having` matcher import `FILTER_TEXT_CASES` and answer those rows; this adapter
was the last face that did not.

- The four AST arms compare exactly. `VALID_AST_OPERATORS` has `icontains` and
  no other `i`-prefixed spelling, so case-sensitive is the only reading the
  other three have — and the one `not_contains` needs so no row can fail an
  operator and its negation.
- `icontains` takes the spec's `asciiCaseInsensitiveContains`: `toLowerCase()`
  is the full Unicode fold, a promise the SQLite-backed drivers cannot keep.
- The `$` dialect follows: `$contains` compares exactly and `$icontains` gains
  an arm — it had none, and an unrecognised `$` operator in that switch adds no
  constraint, so it used to select every row.

The pin asserts row SETS in pairs over one fixture: the case-differing row
`contains` must exclude, and the `icontains` query that must still include it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.7 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-DJaaem3j.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.55KB 114.03KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.68KB 63.94KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.38KB 15.72KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-justin
os-justin marked this pull request as ready for review September 7, 2026 23:24
@os-justin
os-justin enabled auto-merge September 7, 2026 23:24

Copy link
Copy Markdown
Collaborator Author

PM contract review — accepted, flipped to ready, auto-merge armed.

The one thing you flagged for me: keep icontains in, do not split

You fixed a second axis the card never named — icontains folded via String.prototype.toLowerCase() (full Unicode) where the contract folds ASCII only (objectstack#4706 Q1 = A, because three of five backends are SQLite and its lower() folds ASCII only) — and asked whether the PM wants it split out.

Keep it. Grounds:

  • Splitting ships a half-fix that makes the documented escape hatch wrong. This card's whole migration story is "a filter meaning match regardless of case is authored as icontains". Landing contains alone would point authors at an operator that is, on this face, still wider than every driver — a worse state than today, because today at least the two are consistently wrong together.
  • The narrowing you introduce is not a new divergence, it removes one: non-ASCII rows already fail to match under icontains on every real driver. This face was the outlier in both directions.
  • It meets the bounded in-place bar on every count you list — same defect class, same file, mechanical, shape already pinned by the spec's own exported asciiCaseInsensitiveContains, no new verification surface.

The minor bump and the migration note in the changeset are right for exactly this reason: shipped results move.

Three corrections against my brief, all accepted

① My pin recipe was insufficient — ValueDataSource has TWO matchers. A pin written only in the AST dialect passes green while $contains keeps folding. You pinned both. My recipe assumed one seam.

② My "an operator-name assertion passes on a driver that returns everything" warning has a mirror I did not name. An inclusion assertion is equally satisfied by a matcher returning everything — which is precisely what $icontains did before this PR (no arm at all, default: break, no constraint). So "the inclusion half rules out the [] implementation" is only half the guard. Your explicit not-everything assertion for that dialect is the other half, and I am carrying it into future briefs.

③ My escalation fence would have misfired on a survey scoped to this repo. Inside objectui, ValueDataSource very nearly is the only driver — data-objectstack is a translator, ApiDataSource and the runner's mock implement no text operator at all. The deciding siblings all live in objectstack. A seat that read my fence literally and surveyed only objectui would have found no sibling and escalated a defect as a ruling. The fence should have said survey the drivers wherever they live.

Also noted: an existing green test pinned the bug{ name: { $contains: 'ali' } } expecting Alice was reachable only under folding, and its comment shows the author's model was plain substring. Retriaging it to 'Ali' rather than deleting it is the right call.

Your two findings

Both are real and neither belongs in this PR. I am filing them:

  • A — the $-dialect matcher's default: break waves through every unrecognised operator with no constraint ($nin, $startsWith, $null, $exists all measured returning every row). Same class as objectui#7349, in the dialect that card did not touch.
  • Bnot_contains on a non-string stored value fails the operator and its negation, against the platform's 2026-09-05 ruling (objectstack#14079 option A).

Both move results for spellings beyond this card's scope, so each wants its own card and its own fence — exactly as you scoped them.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: ValueDataSource's contains is case-INSENSITIVE, so it executes icontains semantics — the spec keeps the two operators deliberately distinct

2 participants